"use client"; /** * CheckoutClient * * Purchase-first flow. * * Unauthenticated → POST /api/checkout/guest → pay. * Authenticated → server-rendered session URL → pay. * * "Pay" is one of two things. With a Stripe publishable key configured the * payment form is embedded in this page, and on completion we navigate to * /checkout/success ourselves — Stripe's embedded form otherwise leaves the * customer on a "payment received" panel with nowhere to go. Without a * publishable key the browser goes to Stripe's hosted page and comes back. * Both take money; the embedded one loses fewer people on the way. * * After payment the user lands on /checkout/success, where they create an * account (email + password, no phone) or sign in, and are then sent to * /activate/[orderId]. * * Every string on this page is read by a customer. It names no environment * variable and gives no configuration instructions — an operator's deployment * problem is not something a shopper can act on, and printing it makes a store * look broken rather than busy. */ import { useCallback, useEffect, useRef, useState } from "react"; import { useAuth } from "@clerk/nextjs"; import { useRouter } from "next/navigation"; import { EmbeddedStripeCheckout, canEmbedStripeCheckout, } from "@/components/checkout/EmbeddedStripeCheckout"; import { conversionEvents } from "@/lib/conversion-events"; import { normalizeChannel } from "@/vendor/carrier/catalog-guard"; import type { SkuTemplate } from "@/vendor/carrier/types"; interface Props { plan: SkuTemplate; /** Pre-built session URL for authenticated users (server-side). Null for guests. */ session: { url: string } | null; } const GENERIC_CHECKOUT_ERROR = "Checkout is unavailable right now. Please try again in a moment."; /** * Where this visit came from, read off the landing URL. * * `?channel=` is the explicit form; `utm_source` is what ad and email tools * actually append, so both are honoured. The value is normalised here and * again on the server — this copy only keeps the request tidy. */ function readChannel(): string { if (typeof window === "undefined") return "storefront"; const params = new URLSearchParams(window.location.search); return normalizeChannel(params.get("channel") ?? params.get("utm_source")); } /** Internal signal: the embedded path failed but a hosted one is still worth trying. */ class EmbedUnavailable extends Error { constructor() { super("embed_unavailable"); this.name = "EmbedUnavailable"; } } export function CheckoutClient({ plan, session }: Props) { const { isSignedIn, isLoaded } = useAuth(); const router = useRouter(); const [checkoutError, setCheckoutError] = useState(null); const [loading, setLoading] = useState(false); const [clientSecret, setClientSecret] = useState(null); const [sessionId, setSessionId] = useState(null); const guestCheckoutPlanIdRef = useRef(null); const authRefreshAttemptedRef = useRef(false); /** * Set once the embedded attempt has been given up on. The retry is hosted, * and the flag stops a failing embed from looping through this component. */ const embedAbandonedRef = useRef(false); const startGuestCheckout = useCallback( (wantEmbed: boolean) => { // Named inner function so the hosted retry can call itself without the // callback depending on its own identity. const run = (embed: boolean): void => { setLoading(true); fetch("/api/checkout/guest", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ templateId: plan.id, channel: readChannel(), ...(embed ? { uiMode: "embedded" } : {}), }), }) .then(async (res) => { if (!res.ok) { // The route returns customer-safe copy for every refusal it knows // about. Anything else (a proxy error page, say) gets the generic // line rather than whatever text happened to come back. const err = (await res.json().catch(() => ({}))) as { error?: string; code?: string }; if (embed && err.code === "embed_unavailable") throw new EmbedUnavailable(); throw new Error(err.code && err.error ? err.error : GENERIC_CHECKOUT_ERROR); } return res.json() as Promise<{ url?: string; clientSecret?: string; sessionId?: string; }>; }) .then((data) => { if (embed && data.clientSecret && data.sessionId) { setClientSecret(data.clientSecret); setSessionId(data.sessionId); setLoading(false); return; } if (data.url) { window.location.href = data.url; return; } throw new Error(GENERIC_CHECKOUT_ERROR); }) .catch((err: unknown) => { if (err instanceof EmbedUnavailable && !embedAbandonedRef.current) { // The embed could not be created. A hosted session still can be, // and a customer who came here to pay should be allowed to. embedAbandonedRef.current = true; run(false); return; } guestCheckoutPlanIdRef.current = null; setCheckoutError(err instanceof Error ? err.message : GENERIC_CHECKOUT_ERROR); setLoading(false); }); }; run(wantEmbed); }, [plan.id], ); useEffect(() => { if (!isLoaded) return; conversionEvents.beginCheckout(plan.name, plan.price_cents / 100); if (isSignedIn) { // Authenticated path: use the server-rendered session URL. if (session?.url) { window.location.href = session.url; return; } if (!authRefreshAttemptedRef.current) { authRefreshAttemptedRef.current = true; router.refresh(); return; } setCheckoutError(GENERIC_CHECKOUT_ERROR); return; } // Guest path: no auth required. if (guestCheckoutPlanIdRef.current === plan.id) return; guestCheckoutPlanIdRef.current = plan.id; startGuestCheckout(canEmbedStripeCheckout); }, [isLoaded, isSignedIn, plan, session, router, startGuestCheckout]); /** * The payment cleared inside the iframe and no redirect was required. * * `replace`, not `push`: the back button must not return to a Checkout * Session that has already been paid. */ const handleComplete = useCallback(() => { const params = new URLSearchParams({ templateId: plan.id }); if (sessionId) params.set("session_id", sessionId); router.replace(`/checkout/success?${params.toString()}`); }, [plan.id, router, sessionId]); /** Stripe.js never loaded. Fall back to the page Stripe hosts itself. */ const handleEmbedUnavailable = useCallback(() => { if (embedAbandonedRef.current) { setCheckoutError(GENERIC_CHECKOUT_ERROR); return; } embedAbandonedRef.current = true; setClientSecret(null); setSessionId(null); startGuestCheckout(false); }, [startGuestCheckout]); if (checkoutError) { return (

{checkoutError}

{plan.name}

← Back to shop
); } if (clientSecret) { return (

Almost there.

{plan.name} — your QR code is ready the moment this clears.

); } return (

{loading ? "Preparing your checkout…" : "Redirecting to payment…"}

{plan.name}

); }